-
-
Notifications
You must be signed in to change notification settings - Fork 601
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Create new validation API #129
Conversation
There're things that can still be improved (especially naming) and still need to update the documentation, but would be amazing to have your comments! |
* @return array | ||
* | ||
* @throws InvalidArgumentException When an invalid header is informed | ||
* @throws InvalidArgumentException |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we use an exception from this package?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Planned for #128
public function __construct(Parsing\Encoder $encoder) | ||
{ | ||
$this->encoder = $encoder; | ||
$this->headers = ['typ'=> 'JWT', 'alg' => 'none']; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Move this to defaults
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What you mean? Move to the property definition?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct.
{ | ||
$this->encoder = $encoder; | ||
$this->headers = ['typ'=> 'JWT', 'alg' => 'none']; | ||
$this->claims = []; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Move this to defaults
*/ | ||
public function canOnlyBeUsedBy(string $audience, bool $addHeader = false): BuilderInterface | ||
{ | ||
$audiences = $this->claims['aud'] ?? []; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it possible to use private constants for things like 'aud'
and such? I know they are in the spec, but the verbosity is annoyingly low...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
v4 is meant for php 7.0...
But the constant idea is interesting... RegisteredClaims::AUDIENCE
sounds good...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
However I think we should add it on a different PR
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Issue please?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
|
||
return $this->setRegisteredClaim( | ||
'aud', | ||
array_values(array_map('strval', $audiences)), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should there be an array_unique()
here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It could be added indeed (although 100% unrelated with the changeset AHAHAHH)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
New issue then?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually I'll solve it here, that array_map() + array_values()
is useless now... the type hint already forces it to be string.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(and it was hurting my eyes HAHAHA)
* | ||
* @since 4.0.0 | ||
*/ | ||
final class InvalidTokenException extends Exception |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
extends InvalidArgumentException
? extends UnexpectedValueException
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Exception
is Lcobucci\JWT\Exception
... an initial work of #128.
Didn't want to come to a big inheritance tree now.
public static function fromViolations(ConstraintViolationException ...$violations): self | ||
{ | ||
$exception = new self('The token violates some mandatory constraints'); | ||
$exception->violations = $violations; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Move the violation messages also into the message?
* | ||
* @since 4.0.0 | ||
*/ | ||
final class Validator implements \Lcobucci\JWT\Validator |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
More than a validator, this looks like a composite assertion. A validator simply returns true/false, this thing is actually throwing a ConstraintViolationException
containing multiple previous ConstraintViolationException
instances.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's throwing an InvalidTokenException
with multiple ConstraintViolationException
instances.
What would be the best name in your opinion?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AggregateAssertion
or MultiAssertion
works too... It would still throw a subtype of ConstraintViolationException
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
InvalidTokenException
is not a ConstraintViolationException
and my idea is that users should only know about InvalidTokenException
.
Of course we could turn these into a composite specification (which is closer to what you're describing IMO) but I don't think we need that complexity...
A validator simply returns true/false
I'm a bit skeptical about it... not saying that's not true but it sounds over simplified.
Maybe we can offer both things:
interface Validator
{
/**
* @throws InvalidTokenException
*/
public function assert(Token $token, Constraint ...$constraints): void;
public function validate(Token $token, Constraint ...$constraints): bool;
}
One would use assert()
when is looking for a "detailed report" or validate()
when is just caring about the end result.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In my opinion, validate
should return an array
of ConstraintViolation
(not ConstraintViolationException
!), thus assert
may look like:
public function assert(Token $token, Constraint ...$constraints)
{
if ($violations = $this->validate($token, ...$constraints)) {
throw new InvalidTokenException($violations);
}
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think that the validate()
method name indicates that it should return a boolean. However it's not in accordance with the rest of the changes since you have assert()
methods throwing exceptions while here you have a validation()
method doing the same.
With that in mind I would either call this class *Assertion
or change the implementation of validate()
method. But, throwing an exception every time a token is validated doesn't look very nice, IMO. You may or may not want details about the validation.
Split it into two methods may be a good idea but also looks too much responsibility for the Validator
. I still keep my ide of having a Result
object with the violations as the result of the validate()
method, which in the end will tell you if the validation is okay or not, something like:
class Result
{
private $violations;
public function __construct(array $violations)
{
$this->violations = $violations
}
public function isValid(): bool
{
return empty($violations);
}
public function getViolations(): array
{
return $this->violations;
}
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@henriquemoody what I dislike about having a result object is requiring users to do this when they just care about the result:
if (!$validator->validate($token, ...$constraints)->isValid()) { // or assign a variable for the result ofc
}
Split it into two methods may be a good idea but also looks too much responsibility for the
Validator
.
Both methods are related to the same thing, so the reason to change is the same.
@dannydorfel @henriquemoody @schnittstabil @Ocramius thanks for your suggestions regarding this. I think the best option for now is splitting in two methods.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
$validator = new Validator();
$validator->validate($token, ...$constraints)->isValid()
Eek, 3 times valid on a single line. Wait – 3 times?
The Validator
class is final and doesn't have any state, thus it is only a container or the namespace for the validate
and assert
functions, isn't it? For consumers, this is very inconvenient, I think they should use those functions directly:
use function Lcobucci\JWT\assert;
use function Lcobucci\JWT\validate;
assert($token, ...$constraints);
$result = validate($token, ...$constraints); // whatever result will be
Btw, because of this, I don't know if the Validator
class and the Validator
interface are need at all. Interop? – Questionable. Of course, they can be kept for internal usage, but I think providing only the functions is much more appropriate.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another suggestion:
use function Lcobucci\JWT\assert; // clearly throws Exceptions
use function Lcobucci\JWT\isValid; // clearly returns boolean values
use function Lcobucci\JWT\validate; // returns ValidationResult objects, IMO acceptable
$violations = $this->assert($violations, $constraint, $token); | ||
} | ||
|
||
if (!empty($violations)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if ($violations)
;-)
$violations = []; | ||
|
||
foreach ($constraints as $constraint) { | ||
$violations = $this->assert($violations, $constraint, $token); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is overwriting the previous $violations
. Some confusion around this code.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Didn't want to use &$violations
there...
Nice! And now I understand the "case of the Mondays" tweet. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ConstraintViolation
instead of ConstraintViolationException
– Looks like github falsely belives the comments below are outdated, please click on Show outdated manually.
I don't think we need a ConstraintViolationException
class. I've seen this in many assertion/validation libraries, but just because it's sometimes convenient doesn't mean it's the right thing.
Exception
s implementgetFile
,getLine
andgetTrace
, but these don't describe the location/path (e.g.nbf
) within the token, it's the location within the jwt source code – rather useless for a consumer.Exception
s are thrown, this means aConstraintViolationException
s describes single violation (wo composite pattern or similar) - see myValidAt::assert
comment
Instead we need a ConstraintViolation
class, which fits our needs, e.g. with a specialized __toString()
.
* | ||
* @since 4.0.0 | ||
*/ | ||
final class Validator implements \Lcobucci\JWT\Validator |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In my opinion, validate
should return an array
of ConstraintViolation
(not ConstraintViolationException
!), thus assert
may look like:
public function assert(Token $token, Constraint ...$constraints)
{
if ($violations = $this->validate($token, ...$constraints)) {
throw new InvalidTokenException($violations);
}
}
$this->assertExpiration($token); | ||
$this->assertBeforeThanNow($claimSet, 'nbf', 'The token cannot be used yet'); | ||
$this->assertBeforeThanNow($claimSet, 'iat', 'The token was issued in the future'); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The first ConstraintValidationException
wins – In my opinion, we should instead implement validate
:
/**
* @return ConstraintValidation[]
*/
public function validate(Token $token)
{
$claimSet = $token->claims();
return array_merge(
$this->validateExpiration($token),
$this->validateBeforeThanNow($claimSet, 'nbf', 'The token cannot be used yet'),
$this->validateBeforeThanNow($claimSet, 'iat', 'The token was issued in the future')
);
}
/**
* @return ConstraintValidation[]
*/
private function validateExpiration(Token $token)
…
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This also means, we can drop the ugly try
-catch
at Validator::assert
.
* | ||
* @since 4.0.0 | ||
*/ | ||
interface Validator |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, I don't know if this was already discussed, but how do we want to use Validators?
Currently(?):
$config = new Configuration();
$validator = $config->getValidator();
$validator->validate($token, new ValidAt(new DateTimeImmutable()));
$validator->validate($token, new IssuedBy('test.com'));
As Configuration
serves like a small DI container, there are also other possibilities, e.g.:
// during bootstrapping
$jwtConfig = new Configuration();
$jwtConfig->getValidator()
->addConstraint(new ValidAt(new DateTimeImmutable()))
->addConstraint(new IssuedBy('test.com'));
// somewhere else
$jwtConfig->getValidator()->validate($token);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, I don't know if this was already discussed, but how do we want to use Validators?
$config = new Configuration();
$validator = $config->getValidator();
$validator->validate(
$token,
new ValidAt(new DateTimeImmutable()),
new IssuedBy('test.com')
);
// or simply
$constraints = [new ValidAt(new DateTimeImmutable()), new IssuedBy('test.com')];
$validator->validate($token, ...$constraints);
As Configuration serves like a small DI container, there are also other possibilities, e.g.:
I don't see changing the state of the service as an option, IMO that's not a good practice. Also, new ValidAt(new DateTimeImmutable())
might lead to some issues due to the creation and the validation time.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That was some food for thought – changing the state is one of many possibilities, but you're right the splat operator offers great options for code reuse. The more I think about it, the more I love it.
525f3dc
to
2373e57
Compare
|
||
return $this; | ||
} | ||
public function with(string $name, $value): Builder; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Only with()
looks a little bit vague to me, wouldn't be better to call it withClaim
instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Indeed, change needed
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
$this->with($name, $value); | ||
|
||
if ($addHeader) { | ||
$this->headers[$name] = $this->claims[$name]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Although they're private properties and you know what with()
does, I would create some method to get the claims by name, that you could also use on canOnlyBeUsedBy()
, and I would also use withHeader()
instead of setting $this->headers
manually.
return $this; | ||
} | ||
|
||
private function encode(array $items): string |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't this useful on the Encoder itself?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure... sometimes you don't want to encode with JSON+Base64Url (binary data is just base64url for example)
|
||
public function violations(): array | ||
{ | ||
return $this->violations; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is going to break if someone use the exception with the constructor. Just to be more defensive I would either define it as an array when declaring the property or pass the violations in the constructor.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Solved, thanks 😉
71df5ee
to
5876bf4
Compare
$addHeader | ||
); | ||
} | ||
public function canOnlyBeUsedBy(string $audience, bool $addHeader = false): Builder; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
belongsTo
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not really, audience
is about who can consume the token.
I wouldn't change it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Docblock specifies "appends", method name suggests "only". :confus:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
33a81ec
to
87c7fda
Compare
Code changed... @Ocramius are you fine with merging this as is now and implement the exception improvements in another PR?
87c7fda
to
d848c69
Compare
$addHeader | ||
); | ||
} | ||
public function canOnlyBeUsedBy(string $audience, bool $addHeader = false): Builder; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Docblock specifies "appends", method name suggests "only". :confus:
|
||
return $this; | ||
} | ||
public function with(string $name, $value): Builder; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Indeed, change needed
* Returns the resultant token | ||
* | ||
* @return Token | ||
* Returns an unsecured token (not recommended) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wouldn't it make more sense to use the previous API with a no-op signer?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What you mean here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Have always a token, let a NoneSigner
deal with it (validates only empty signature, generates only empty signature)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's a good idea, it would simplify things indeed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But it can be done in a different PR... I really want to have this merged instead of adding things to the stack
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
#152 is there for that.
return $this->validator; | ||
} | ||
|
||
public function setValidator(Validator $validator) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should have a @return void
docblock
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wouldn't bother with that now since I want to merge it and work on #146
* @return mixed | ||
* | ||
* @throws OutOfBoundsException | ||
* Returns if the token minimum time is before than given time |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minimum time of what?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It validates the "not before" claim, so in theory it's the minimum time of usage but I didn't have a better name... any suggestion?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, that's confusing because of the not
in not before
:-\
I guess that's what the spec is - can't fix the naming then.
*/ | ||
public function __toString(): string | ||
{ | ||
return implode('.', get_object_vars($this)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider getting rid of @get_object_vars()
here, as no order is guaranteed
* | ||
* @throws ConstraintViolationException | ||
*/ | ||
public function assert(Token $token); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
/** | ||
* @param Token $token | ||
* | ||
* @throws ConstraintViolationException |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add @return void
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wouldn't bother with that now since I want to merge it and work on #146
$violations | ||
); | ||
|
||
$message = 'The token violates some mandatory constraints, details:' . PHP_EOL; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd just use \n
); | ||
|
||
$message = 'The token violates some mandatory constraints, details:' . PHP_EOL; | ||
$message .= implode(PHP_EOL, $violations); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Samee: \n
d848c69
to
42ab523
Compare
Simplifying the whole thing and making it easier to create new signers.
Yes this is a big change... brace yourself! The ideia here is to make things more extensible and decoupled. So what I did was: - Extract interfaces for the important stuff to isolate the namespaces - Create the DataSet class (definitely not best name) to remove duplication on headers and claims manipulation - Make (almost) everything final - Group token related stuff under a namespace - Create methods to encapsulate token verifications Fixes: #117
And so remove all the the existing mutations.
And remove things that are not useful now that we have scalar type hints. Fixes #131
42ab523
to
4fb4d79
Compare
…tor-extraction"
Yes @Ocramius @henriquemoody @schnittstabil @dannydorfel, after a looong time we finally have something to solve the validation API 🎉
Brace yourself because this is huge PR but after merging it we'll haz: